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:de129bcM1 Fresh InterpolationManager port + retail spec40d88b9M2 Queue routing for player-remote UPs + entity-position sync2365c8cM3 Animation root motion fallback for idle queued57ace0M6 Cleanup — dead fields + stale env-var referencesc26bbbbM4 Jump-CellId fix + #42 filedb37b713#42 root cause confirmed via A/B test5cc2812Handoff 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:
commit
086e65dfe6
22 changed files with 11199 additions and 459 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,32 +7,34 @@ namespace AcDream.Core.Physics;
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// InterpolationManager — retail CPhysicsObj interpolation queue.
|
||||
//
|
||||
// Ports:
|
||||
// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
|
||||
// InterpolationManager::adjust_offset (acclient @ 0x00555D30)
|
||||
// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip
|
||||
// Source spec: docs/research/2026-05-04-l3-port/04-interp-manager.md
|
||||
// Retail addresses (Sept-2013 EoR PDB):
|
||||
// InterpolationManager::InterpolateTo acclient @ 0x00555B20
|
||||
// InterpolationManager::adjust_offset acclient @ 0x00555D30
|
||||
// InterpolationManager::UseTime acclient @ 0x00555F20
|
||||
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
|
||||
// InterpolationManager::StopInterpolating acclient @ 0x00555950
|
||||
//
|
||||
// FIFO position-waypoint queue (cap 20). On each physics tick the caller
|
||||
// passes current body position + max-speed from the motion table; we return
|
||||
// the delta vector to apply to the body for this frame.
|
||||
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
|
||||
// current body position + max-speed from the motion table; we return the
|
||||
// world-space delta vector to apply to the body for this frame.
|
||||
//
|
||||
// Queue semantics:
|
||||
// - Head = next target. Body walks toward head at catch-up speed.
|
||||
// - Tail = most-recent server position. On stall we blip directly to tail
|
||||
// (retail UseTime @ 0x00555F20: copies tail_ position, calls
|
||||
// CPhysicsObj::SetPositionSimple, then StopInterpolating).
|
||||
// Public C# API kept Vector3-based for compatibility with PositionManager and
|
||||
// GameWindow callsites; retail-spec method names are documented inline. The
|
||||
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
|
||||
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
|
||||
// queue's purposes — see audit 04-interp-manager.md § 4.
|
||||
//
|
||||
// Constants verified from named binary at the addresses cited above (not
|
||||
// guesses):
|
||||
// MAX_INTERPOLATED_VELOCITY_MOD = 2.0
|
||||
// MAX_INTERPOLATED_VELOCITY = 7.5
|
||||
// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters)
|
||||
// DESIRED_DISTANCE = 0.05
|
||||
// Bug fixes applied vs prior port (audit § 7):
|
||||
// #1: progress_quantum accumulates dt (not step magnitude).
|
||||
// #3: far-branch Enqueue sets node_fail_counter = 4 → immediate next-tick
|
||||
// blip-to-tail. Triggered by distance > AutonomyBlipDistance (100 m).
|
||||
// #4: secondary stall test ports the retail formula verbatim:
|
||||
// cumulative_progress / progress_quantum / dt < 0.30.
|
||||
// #5: tail-prune is a tail-walking loop (collapses multiple stale entries).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Waypoint used internally by <see cref="InterpolationManager"/>.
|
||||
/// </summary>
|
||||
/// <summary>Internal queue node. type=1 = Position waypoint (only kind we use).</summary>
|
||||
internal sealed class InterpolationNode
|
||||
{
|
||||
public Vector3 TargetPosition;
|
||||
|
|
@ -41,7 +43,7 @@ internal sealed class InterpolationNode
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity position interpolation queue. Caller enqueues server
|
||||
/// Per-remote-entity position interpolation queue. Caller enqueues server
|
||||
/// position updates and calls <see cref="AdjustOffset"/> once per physics
|
||||
/// tick to get the per-frame correction delta.
|
||||
/// </summary>
|
||||
|
|
@ -49,281 +51,339 @@ public sealed class InterpolationManager
|
|||
{
|
||||
// ── public constants (retail binary values) ───────────────────────────────
|
||||
|
||||
/// <summary>Maximum waypoints held before oldest is dropped.</summary>
|
||||
/// <summary>Maximum waypoints held before oldest (head) is dropped.</summary>
|
||||
public const int QueueCap = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier.
|
||||
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30).
|
||||
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122).
|
||||
/// </summary>
|
||||
public const float MaxInterpolatedVelocityMod = 2.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Fallback catch-up speed (m/s) when motion-table max speed is
|
||||
/// unavailable (zero/tiny).
|
||||
/// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30).
|
||||
/// unavailable. Retail MAX_INTERPOLATED_VELOCITY (@ 0x40f00000 line 353137).
|
||||
/// </summary>
|
||||
public const float MaxInterpolatedVelocity = 7.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Per-5-frame stall progress threshold (meters). Body must advance at
|
||||
/// least this far in <see cref="StallCheckFrameInterval"/> frames or
|
||||
/// the window counts as a stall.
|
||||
/// Per-5-frame stall progress threshold (meters).
|
||||
/// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
|
||||
/// </summary>
|
||||
public const float MinDistanceToReachPosition = 0.20f;
|
||||
|
||||
/// <summary>
|
||||
/// Reach + duplicate-prune radius (meters). Node is popped when
|
||||
/// distance to its target falls below this value; new enqueues within
|
||||
/// this distance of the tail are ignored.
|
||||
/// Reach + duplicate-prune radius (meters).
|
||||
/// Retail DESIRED_DISTANCE (@ 0x00555D30).
|
||||
/// </summary>
|
||||
public const float DesiredDistance = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Number of ticks between stall progress checks.
|
||||
/// Number of ticks per stall progress check window.
|
||||
/// Retail frame_counter threshold (@ 0x00555E14).
|
||||
/// </summary>
|
||||
public const int StallCheckFrameInterval = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum fraction of cumulative progress_quantum that counts as "real
|
||||
/// progress" in a stall check window. Below this fraction the window
|
||||
/// counts as a stall (secondary check, applies when progress_quantum > 0).
|
||||
/// Secondary stall ratio threshold — port verbatim from retail.
|
||||
/// Audit notes the formula has odd units (1/sec); not our bug to fix.
|
||||
/// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
|
||||
/// </summary>
|
||||
public const float StallProgressMinFraction = 0.30f;
|
||||
|
||||
/// <summary>
|
||||
/// Stall-fail counter threshold. The body is blipped to the tail of the
|
||||
/// queue when <c>node_fail_counter</c> EXCEEDS this value (i.e., on the
|
||||
/// 4th consecutive failed window, not the 3rd).
|
||||
/// Retail: <c>node_fail_counter > 3</c> (@ 0x00555F39).
|
||||
/// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this
|
||||
/// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail > 3.
|
||||
/// </summary>
|
||||
public const int StallFailCountThreshold = 3;
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private readonly LinkedList<InterpolationNode> _queue = new();
|
||||
|
||||
/// <summary>Frames elapsed since the last 5-frame stall-check window fired.</summary>
|
||||
private int _framesSinceLastStallCheck = 0;
|
||||
/// <summary>
|
||||
/// Distance threshold (meters) above which an Enqueue is treated as a far
|
||||
/// jump and pre-arms an immediate blip. Retail outdoor value; indoor is
|
||||
/// 20 m. Bug #3 fix from audit § 7.
|
||||
/// </summary>
|
||||
public const float AutonomyBlipDistance = 100.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Cumulative sum of per-frame <c>step</c> magnitudes within the current
|
||||
/// 5-frame window. Retail <c>progress_quantum</c>.
|
||||
/// Sentinel for original_distance before the first window baseline is
|
||||
/// taken. Retail value (@ 0x00555D30 ctor) is 999999f.
|
||||
/// </summary>
|
||||
private float _progressQuantum = 0f;
|
||||
public const float OriginalDistanceSentinel = 999999f;
|
||||
|
||||
/// <summary>
|
||||
/// Distance to the head node recorded at the START of the current
|
||||
/// 5-frame window. Retail <c>original_distance</c>.
|
||||
/// </summary>
|
||||
private float _distanceAtWindowStart = 0f;
|
||||
private const float FEpsilon = 0.0002f;
|
||||
|
||||
/// <summary>
|
||||
/// True once the first valid distance sample has been taken and
|
||||
/// <c>_distanceAtWindowStart</c> is populated. Guards against the
|
||||
/// first-window false-positive that occurs when the field defaults to 0.
|
||||
/// </summary>
|
||||
private bool _haveBaselineDistance = false;
|
||||
// ── internals (retail field names in comments) ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive 5-frame windows that failed both the absolute
|
||||
/// and ratio progress checks. Retail <c>node_fail_counter</c>.
|
||||
/// Blip fires when this EXCEEDS <see cref="StallFailCountThreshold"/>.
|
||||
/// </summary>
|
||||
private int _failCount = 0;
|
||||
private readonly LinkedList<InterpolationNode> _queue = new(); // position_queue
|
||||
|
||||
private int _frameCounter = 0; // frame_counter
|
||||
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
|
||||
private float _originalDistance = OriginalDistanceSentinel; // original_distance
|
||||
private int _failCount = 0; // node_fail_counter
|
||||
|
||||
// ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when the queue holds at least one waypoint.</summary>
|
||||
public bool IsActive => _queue.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Current waypoint count (visible to the test assembly for cap verification).
|
||||
/// </summary>
|
||||
/// <summary>Current waypoint count (visible to tests for cap verification).</summary>
|
||||
internal int Count => _queue.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Stop interpolating: clear the queue and reset all stall counters.
|
||||
/// Retail StopInterpolating / destructor cleanup.
|
||||
/// Stop interpolating: drain queue and reset all stall state to sentinel
|
||||
/// values. Retail StopInterpolating (@ 0x00555950).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_queue.Clear();
|
||||
_framesSinceLastStallCheck = 0;
|
||||
_progressQuantum = 0f;
|
||||
_distanceAtWindowStart = 0f;
|
||||
_haveBaselineDistance = false;
|
||||
_failCount = 0;
|
||||
_frameCounter = 0;
|
||||
_progressQuantum = 0f;
|
||||
_originalDistance = OriginalDistanceSentinel;
|
||||
_failCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue a new server-authoritative position waypoint.
|
||||
///
|
||||
/// <para>
|
||||
/// Step 1: Duplicate-prune — if the new target is within
|
||||
/// <see cref="DesiredDistance"/> of the current tail, ignore it.<br/>
|
||||
/// Step 2: Cap — if the queue is already at <see cref="QueueCap"/>,
|
||||
/// drop the oldest (head) entry.<br/>
|
||||
/// Step 3/4: Append a new <see cref="InterpolationNode"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0).
|
||||
/// Enqueue a new server-authoritative waypoint. Implements retail
|
||||
/// <c>InterpolateTo</c> branching:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Already-close</b>: if distance(body, target) ≤
|
||||
/// <see cref="DesiredDistance"/>, queue is wiped (StopInterpolating)
|
||||
/// and no node is enqueued.</item>
|
||||
/// <item><b>Far</b>: if distance(reference, target) >
|
||||
/// <see cref="AutonomyBlipDistance"/>, enqueue and set
|
||||
/// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an
|
||||
/// immediate blip on the next AdjustOffset call.</item>
|
||||
/// <item><b>Near</b>: tail-prune loop collapses adjacent stale entries
|
||||
/// within <see cref="DesiredDistance"/>; cap at 20 (head eviction);
|
||||
/// enqueue.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="targetPosition">Server-reported world position.</param>
|
||||
/// <param name="heading">Server-reported heading (radians, AC convention).</param>
|
||||
/// <param name="isMovingTo">True when the body is in motion — gates heading validity.</param>
|
||||
public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo)
|
||||
/// <param name="heading">Server-reported heading (radians).</param>
|
||||
/// <param name="isMovingTo">True when body is currently following an MTP.</param>
|
||||
/// <param name="currentBodyPosition">
|
||||
/// Body's current world position. Used for the already-close check (versus
|
||||
/// body) and as the fallback distance reference when the queue is empty.
|
||||
/// Pass <c>null</c> if not available — far/near classification falls back
|
||||
/// to "near" (no pre-armed blip).
|
||||
/// </param>
|
||||
public void Enqueue(
|
||||
Vector3 targetPosition,
|
||||
float heading,
|
||||
bool isMovingTo,
|
||||
Vector3? currentBodyPosition = null)
|
||||
{
|
||||
// Step 1: duplicate-prune
|
||||
if (_queue.Last is { } last)
|
||||
// Retail compares dist against either the tail's stored position
|
||||
// (if tail exists AND tail->type == 1) or the body's m_position.
|
||||
Vector3 reference;
|
||||
bool haveTail = _queue.Last is { } tail;
|
||||
if (haveTail)
|
||||
{
|
||||
if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance)
|
||||
return;
|
||||
reference = _queue.Last!.Value.TargetPosition;
|
||||
}
|
||||
else if (currentBodyPosition.HasValue)
|
||||
{
|
||||
reference = currentBodyPosition.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
reference = targetPosition; // dist = 0 → near branch
|
||||
}
|
||||
|
||||
// Step 2: enforce cap
|
||||
float dist = Vector3.Distance(reference, targetPosition);
|
||||
|
||||
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
|
||||
if (dist > AutonomyBlipDistance)
|
||||
{
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
|
||||
_failCount = StallFailCountThreshold + 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Near & already-close branch (retail line 352962):
|
||||
// distance(body, target) ≤ DesiredDistance → wipe queue, no enqueue.
|
||||
if (currentBodyPosition.HasValue)
|
||||
{
|
||||
float bodyDist = Vector3.Distance(currentBodyPosition.Value, targetPosition);
|
||||
if (bodyDist <= DesiredDistance)
|
||||
{
|
||||
Clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Near & not-close branch:
|
||||
// 1. Tail-prune loop — collapse all consecutive stale tail entries
|
||||
// within DesiredDistance of the new target (audit § 7 #5).
|
||||
while (_queue.Last is { } stale &&
|
||||
Vector3.Distance(stale.Value.TargetPosition, targetPosition) <= DesiredDistance)
|
||||
{
|
||||
_queue.RemoveLast();
|
||||
}
|
||||
|
||||
// 2. Cap at 20 — drop head (audit § 7 #6).
|
||||
if (_queue.Count >= QueueCap)
|
||||
_queue.RemoveFirst();
|
||||
|
||||
// Steps 3+4: add node
|
||||
var node = new InterpolationNode
|
||||
// 3. Append.
|
||||
EnqueueRaw(targetPosition, heading, isMovingTo);
|
||||
}
|
||||
|
||||
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
|
||||
{
|
||||
_queue.AddLast(new InterpolationNode
|
||||
{
|
||||
TargetPosition = targetPosition,
|
||||
TargetPosition = target,
|
||||
Heading = heading,
|
||||
IsHeadingValid = isMovingTo,
|
||||
};
|
||||
_queue.AddLast(node);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the per-frame position correction delta.
|
||||
/// Compute the per-frame world-space correction delta. Combines the retail
|
||||
/// <c>UseTime</c> blip-check (fail_count > 3 → snap to tail, clear queue)
|
||||
/// with the per-frame <c>adjust_offset</c> step computation.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <see cref="Vector3.Zero"/> when the queue is empty or when
|
||||
/// the head node has been reached. Returns a snap delta (tail −
|
||||
/// currentBodyPosition) after <see cref="StallFailCountThreshold"/>
|
||||
/// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
|
||||
/// then clears the queue.
|
||||
/// </para>
|
||||
/// Returns <see cref="Vector3.Zero"/> when:
|
||||
/// • queue is empty,
|
||||
/// • head reached (distance < <see cref="DesiredDistance"/>) — head pops,
|
||||
/// • dt is invalid (≤ 0 or NaN).
|
||||
///
|
||||
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
|
||||
/// UseTime stall/blip (@ 0x00555F20).
|
||||
/// Returns the snap delta (tail − currentBodyPosition) when fail_count
|
||||
/// exceeds <see cref="StallFailCountThreshold"/>, then clears the queue.
|
||||
/// </summary>
|
||||
/// <param name="dt">Frame delta time (seconds).</param>
|
||||
/// <param name="currentBodyPosition">Current world-space body position.</param>
|
||||
/// <param name="maxSpeedFromMinterp">
|
||||
/// Max motion-table speed for this entity's current cycle (m/s), as
|
||||
/// reported by MotionInterpreter. Pass 0 if unavailable; the fallback
|
||||
/// <see cref="MaxInterpolatedVelocity"/> will be used.
|
||||
/// Max motion-table speed for this entity's current cycle (m/s).
|
||||
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
|
||||
/// </param>
|
||||
/// <returns>World-space delta to apply to the body this frame.</returns>
|
||||
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
|
||||
{
|
||||
// Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position.
|
||||
if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero;
|
||||
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
|
||||
if (dt <= 0 || double.IsNaN(dt))
|
||||
return Vector3.Zero;
|
||||
|
||||
// Step 1: empty queue → no correction
|
||||
if (_queue.First is null)
|
||||
return Vector3.Zero;
|
||||
|
||||
// Step 2: peek head
|
||||
var headNode = _queue.First.Value;
|
||||
// Distance to head node (retail line 353083).
|
||||
var head = _queue.First.Value;
|
||||
float dist = Vector3.Distance(head.TargetPosition, currentBodyPosition);
|
||||
|
||||
// Step 3: distance to head target
|
||||
float dist = (headNode.TargetPosition - currentBodyPosition).Length();
|
||||
|
||||
// Step 4: reached node
|
||||
if (dist < DesiredDistance)
|
||||
// Reach test (retail line 353089): dist ≤ DESIRED_DISTANCE → pop and
|
||||
// re-baseline. NodeCompleted(1) advances to next head, also resets the
|
||||
// window state.
|
||||
if (dist <= DesiredDistance)
|
||||
{
|
||||
_queue.RemoveFirst();
|
||||
NodeCompleted(popHead: true, currentBodyPosition);
|
||||
return Vector3.Zero;
|
||||
}
|
||||
|
||||
// Step 5: compute catch-up speed
|
||||
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
|
||||
float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity;
|
||||
// Catch-up speed (retail line 353122 + 353128 fallback).
|
||||
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
|
||||
float catchUp = scaled > FEpsilon ? scaled : MaxInterpolatedVelocity;
|
||||
|
||||
// Step 6: step magnitude (no overshoot)
|
||||
float step = catchUpSpeed * (float)dt;
|
||||
if (step > dist)
|
||||
step = dist;
|
||||
// Accumulate progress_quantum (audit § 7 #1: SUM OF DT, not step).
|
||||
_progressQuantum += (float)dt;
|
||||
_frameCounter++;
|
||||
|
||||
// Step 7: direction × step
|
||||
Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step;
|
||||
|
||||
// Step 8: stall detection (retail adjust_offset @ 0x00555E08-0x00555E92)
|
||||
//
|
||||
// Retail tracks two quantities across each 5-frame window:
|
||||
// progress_quantum — cumulative sum of per-frame step magnitudes
|
||||
// original_distance — distance to head at the START of the window
|
||||
//
|
||||
// At window end (frame_counter >= 5):
|
||||
// cumulative_progress = original_distance - currentDist
|
||||
//
|
||||
// Primary check (@ 0x00555E42):
|
||||
// cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20 m)
|
||||
// → window is a stall; increment node_fail_counter.
|
||||
//
|
||||
// Secondary check (@ 0x00555E73, only when progress_quantum > 0):
|
||||
// cumulative_progress / progress_quantum < CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30)
|
||||
// → window is a stall; increment node_fail_counter.
|
||||
//
|
||||
// Both checks operate with sticky_object_id == 0 (we never have one).
|
||||
// Either check failing counts the window as a stall.
|
||||
//
|
||||
// Blip fires when node_fail_counter > 3 (retail UseTime @ 0x00555F39).
|
||||
// Window always resets (frame_counter=0, progress_quantum=0,
|
||||
// original_distance=currentDist) after the check.
|
||||
|
||||
// Initialise window baseline on first call after Clear / new motion.
|
||||
if (!_haveBaselineDistance)
|
||||
// 5-frame stall window check (retail line 353146).
|
||||
if (_frameCounter >= StallCheckFrameInterval)
|
||||
{
|
||||
_distanceAtWindowStart = dist;
|
||||
_haveBaselineDistance = true;
|
||||
}
|
||||
float cumulative = _originalDistance - dist;
|
||||
|
||||
_progressQuantum += step;
|
||||
_framesSinceLastStallCheck++;
|
||||
// Primary check (retail line 353150-353166):
|
||||
// cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20)
|
||||
bool primaryPass = cumulative >= MinDistanceToReachPosition;
|
||||
|
||||
if (_framesSinceLastStallCheck >= StallCheckFrameInterval)
|
||||
{
|
||||
float cumulativeProgress = _distanceAtWindowStart - dist;
|
||||
// Secondary check (retail line 353169-353172, audit § 7 #4):
|
||||
// cumulative > F_EPSILON
|
||||
// AND (cumulative / progress_quantum / dt) >= 0.30
|
||||
//
|
||||
// Port verbatim despite weird units; audit notes this may be a
|
||||
// Turbine bug or x87-stack misread by Binary Ninja. Mirroring bytes.
|
||||
bool secondaryPass = false;
|
||||
if (cumulative > FEpsilon && _progressQuantum > 0f && dt > 0)
|
||||
{
|
||||
float ratio = (cumulative / _progressQuantum) / (float)dt;
|
||||
secondaryPass = ratio >= StallProgressMinFraction;
|
||||
}
|
||||
|
||||
bool primaryFail = cumulativeProgress < MinDistanceToReachPosition;
|
||||
bool secondaryFail = _progressQuantum > 0f &&
|
||||
(cumulativeProgress / _progressQuantum) < StallProgressMinFraction;
|
||||
|
||||
if (primaryFail || secondaryFail)
|
||||
if (!primaryPass && !secondaryPass)
|
||||
{
|
||||
_failCount++;
|
||||
// Blip-to-tail: retail UseTime (@ 0x00555F20) reads
|
||||
// position_queue.tail_, copies its position to a local,
|
||||
// calls CPhysicsObj::SetPositionSimple, then
|
||||
// StopInterpolating. Snap target is the TAIL (the most
|
||||
// recent server position), not the head.
|
||||
if (_failCount > StallFailCountThreshold)
|
||||
{
|
||||
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
|
||||
Clear();
|
||||
return tailPos - currentBodyPosition;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_failCount = 0;
|
||||
}
|
||||
|
||||
// Reset the 5-frame window regardless of pass/fail.
|
||||
_framesSinceLastStallCheck = 0;
|
||||
_progressQuantum = 0f;
|
||||
_distanceAtWindowStart = dist;
|
||||
// Re-baseline window regardless of pass/fail.
|
||||
_frameCounter = 0;
|
||||
_progressQuantum = 0f;
|
||||
_originalDistance = dist;
|
||||
}
|
||||
else if (_originalDistance >= OriginalDistanceSentinel - 0.5f)
|
||||
{
|
||||
// First call after Clear / new motion: seed the baseline so the
|
||||
// first 5-frame window's cumulative is computed against frame-0
|
||||
// distance, not the 999999f sentinel. Retail handles this via
|
||||
// the sentinel itself — the sentinel produces a huge cumulative
|
||||
// that always passes — but we use a baseline-seeded approach so
|
||||
// the secondary check has sane progress_quantum behavior.
|
||||
_originalDistance = dist;
|
||||
}
|
||||
|
||||
// Step 9: return per-frame delta
|
||||
// Retail UseTime blip check (@ 0x00555F39): fail_count > 3 → snap to
|
||||
// tail, clear queue. Placed AFTER the stall window logic so it fires
|
||||
// in the same tick as both:
|
||||
// (a) the just-incremented fail_count from a stall window pass, AND
|
||||
// (b) a far-branch Enqueue pre-arm (fail_count = 4 set externally).
|
||||
// Retail splits this into a separate UseTime call; we collapse it.
|
||||
if (_failCount > StallFailCountThreshold)
|
||||
{
|
||||
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
|
||||
Clear();
|
||||
return tailPos - currentBodyPosition;
|
||||
}
|
||||
|
||||
// Per-frame step magnitude (retail line 353218).
|
||||
float step = catchUp * (float)dt;
|
||||
// No-overshoot scaling (retail line 353231): if step would overshoot
|
||||
// dist, clamp to dist.
|
||||
if (step > dist)
|
||||
step = dist;
|
||||
|
||||
// Direction × step.
|
||||
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
|
||||
/// popHead=false during stall fail (re-baseline only). For our collapsed
|
||||
/// architecture we always re-baseline on pop.
|
||||
/// </summary>
|
||||
private void NodeCompleted(bool popHead, Vector3 currentBodyPosition)
|
||||
{
|
||||
_frameCounter = 0;
|
||||
_progressQuantum = 0f;
|
||||
|
||||
if (popHead && _queue.First != null)
|
||||
{
|
||||
_queue.RemoveFirst();
|
||||
}
|
||||
|
||||
// Re-baseline on the new head, or reset to sentinel if queue empty.
|
||||
if (_queue.First is { } newHead)
|
||||
{
|
||||
_originalDistance = Vector3.Distance(newHead.Value.TargetPosition, currentBodyPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
_originalDistance = OriginalDistanceSentinel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue