fix(motion): retail-faithful per-frame remote tick (L.3.2 follow-up)
Multi-bug fix for the env-var-gated retail-faithful remote tick path (ACDREAM_INTERP_MANAGER=1). Combines four previously-stacked defects into one coherent rewrite: 1. PositionManager.ComputeOffset was additive (rootMotion + correction). Retail's PositionManager::adjust_offset (acclient @ 0x00555190 → InterpolationManager::adjust_offset @ 0x00555d30) REPLACES the offset frame via Frame::operator=(arg2, &__return) when catch-up engages — it does NOT add to the rootOffset that CPartArray::Update wrote. Switched to "correction overrides root motion" semantics. 2. MotionInterpreter.GetMaxSpeed was returning RunAnimSpeed × rate (~11.7 m/s for run skill 200). The retail decomp at acclient_2013_pseudo_c.txt:305127 shows get_max_speed returns the bare run rate (~2.94) — the function's float return rides the x87 FPU stack, which Binary Ninja shows as void. Caller multiplies by 2.0 to get the catch-up speed. With the wrong return our catch-up was 23.5 m/s instead of retail's 5.88 m/s — the queue would walk the body 4× too aggressively. 3. The env-var TickAnimations branch was DOUBLE-COUNTING forward translation: it applied seqVel × dt via PositionManager.ComputeOffset AND let UpdatePhysicsInternal advance body.Position += body.Velocity × dt. Both were ~11.7 m/s for run, so body raced at 23.4 m/s — "way too fast" per the user. Pass seqVel=Vector3.Zero to ComputeOffset; let body.Velocity (refreshed per tick by apply_current_movement) drive the bulk translation alone. 4. Body orientation only applied sequencer.CurrentOmega per tick. For the running-in-circles case ACE broadcasts ForwardCommand=RunForward AND TurnCommand=TurnLeft on the same UpdateMotion; the sequencer picks the RunForward cycle whose synthesized CurrentOmega is zero, so body never rotated between UPs and body.Velocity stayed in an out-of-date world direction — the visible "rectangle when running circles" effect. Prefer ObservedOmega (set explicitly in OnLiveMotionUpdated from the wire's TurnCommand + signed TurnSpeed) when present; fall back to seqOmega for standalone turn cycles. Also adds: - Sequencer-reset call in the env-var landing-fallback so the legs un-fold from Falling on land (mirrors the legacy K-fix17 path). - LastServerZ now only updates on IsGrounded UPs, so the per-tick landing-fallback floor doesn't drift up to the player's airborne peak Z and force-land mid-arc — fixes the user-reported "small landing in the air before landing on the ground" when jumping while moving. - VEL_DIAG now samples at UP arrival with overlapping windows, plus TURN_WIRE / OMEGA_DIAG / FWD_WIRE diagnostics gated on ACDREAM_REMOTE_VEL_DIAG=1 used to trace these bugs to ground truth. Verified via live retail-driven character observation 2026-05-03: turn-left now rotates left (was animating right with snap), running in circles is much smoother, jumping lands on ground (no mid-air pause). Residual ~20% steady-state overshoot for walk remains — WalkAnimSpeed=3.12 (decompiled retail constant) doesn't match ACE's actual broadcast walk pace (~2.6 m/s). Tracked separately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9960ce3bce
commit
842dfcd092
3 changed files with 228 additions and 99 deletions
|
|
@ -375,8 +375,14 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
public System.Numerics.Vector3 PrevServerPos;
|
||||
public double PrevServerPosTime;
|
||||
public double LastVelDiagLogTime;
|
||||
public double LastOmegaDiagLogTime;
|
||||
/// <summary>
|
||||
/// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
|
||||
/// all per-tick samples since the last UpdatePosition arrival. The
|
||||
/// next UP compares this against (LastServerPos - PrevServerPos) /
|
||||
/// dtServer to compute the overshoot ratio. Reset on each UP.
|
||||
/// </summary>
|
||||
public float MaxSeqSpeedSinceLastUP;
|
||||
|
||||
public RemoteMotion()
|
||||
{
|
||||
|
|
@ -2782,6 +2788,15 @@ public sealed class GameWindow : IDisposable
|
|||
// to the Attack/Twitch/etc command, and
|
||||
// get_state_velocity returns 0 because the gate is
|
||||
// RunForward||WalkForward — body stops moving forward.
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||
&& remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[FWD_WIRE] guid={update.Guid:X8} "
|
||||
+ $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} "
|
||||
+ $"newCmd=0x{fullMotion:X8} "
|
||||
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
|
||||
}
|
||||
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
|
||||
// Pass speedMod through verbatim — preserve sign so retail's
|
||||
// adjust_motion'd backward walk (cmd=WalkForward, spd<0)
|
||||
|
|
@ -3312,19 +3327,46 @@ public sealed class GameWindow : IDisposable
|
|||
// position only; heading would otherwise lag the queue.
|
||||
rmState.Body.Orientation = rot;
|
||||
|
||||
// Track the most recent server-broadcast Z on EVERY UP — including
|
||||
// mid-arc airborne ones. Read by the per-tick landing-fallback in
|
||||
// TickAnimations: if gravity drags the body below this floor while
|
||||
// still airborne, we force-land locally even when the server never
|
||||
// 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.
|
||||
rmState.LastServerZ = worldPos.Z;
|
||||
//
|
||||
// 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;
|
||||
|
||||
// Diagnostic-only (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
|
||||
// server-pos snapshot forward so the per-tick comparison has a
|
||||
// delta to work with. Cheap (struct copy + double write); not
|
||||
// gated here because the read side gates the actual print.
|
||||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
|
||||
// server-pos snapshot forward AND print the per-UP comparison
|
||||
// between the max sequencer speed observed since last UP and
|
||||
// the actual server broadcast pace. Both sides are now sampled
|
||||
// over the same window so the ratio reflects real overshoot.
|
||||
{
|
||||
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||
&& rmState.LastServerPosTime > 0.0)
|
||||
{
|
||||
double dtServer = nowSecDiag - rmState.LastServerPosTime;
|
||||
if (dtServer > 0.001)
|
||||
{
|
||||
var serverDelta = worldPos - rmState.LastServerPos;
|
||||
float serverSpeed = (float)(serverDelta.Length() / dtServer);
|
||||
float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
|
||||
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
|
||||
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
|
||||
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
rmState.MaxSeqSpeedSinceLastUP = 0f;
|
||||
rmState.PrevServerPos = rmState.LastServerPos;
|
||||
rmState.PrevServerPosTime = rmState.LastServerPosTime;
|
||||
rmState.LastServerPos = worldPos;
|
||||
|
|
@ -3353,6 +3395,22 @@ public sealed class GameWindow : IDisposable
|
|||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||||
rmState.Interp.Clear();
|
||||
rmState.Body.Position = worldPos;
|
||||
|
||||
// Reset the sequencer out of Falling — see matching block in
|
||||
// TickAnimations Step 5 (env-var path) for rationale.
|
||||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
|
||||
&& aeForLand.Sequencer is not null)
|
||||
{
|
||||
uint style = aeForLand.Sequencer.CurrentStyle != 0
|
||||
? aeForLand.Sequencer.CurrentStyle
|
||||
: 0x8000003Du;
|
||||
uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand;
|
||||
if (landingCmd == 0)
|
||||
landingCmd = AcDream.Core.Physics.MotionCommand.Ready;
|
||||
float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed;
|
||||
if (landingSpeed <= 0f) landingSpeed = 1f;
|
||||
aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5853,43 +5911,85 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
|
||||
{
|
||||
// ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ──
|
||||
// ── 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)
|
||||
//
|
||||
// Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal
|
||||
// (acclient @ 0x00513730):
|
||||
// 1+2. animation root motion + interpolation correction (combined)
|
||||
// 2.5 sequencer omega → body orientation (TurnRight/TurnLeft angular velocity)
|
||||
// 3. calc_acceleration (gravity flag → body.Acceleration)
|
||||
// 4. physics integration (gravity for airborne; no-op for grounded)
|
||||
// Mirrors retail CPhysicsObj::UpdateObjectInternal
|
||||
// (acclient @ 0x005156b0) → UpdatePositionInternal (@ 0x00512c30):
|
||||
//
|
||||
// 1. Force grounded transient flags (matches the legacy path
|
||||
// and the gate inside MotionInterpreter.apply_current_movement
|
||||
// which only writes velocity when OnWalkable is set).
|
||||
// 2. apply_current_movement → body.set_local_velocity(get_state_velocity())
|
||||
// Refreshes body.Velocity from the current InterpretedState
|
||||
// every tick. Matches the legacy path that has been working
|
||||
// for player remotes since pre-L.3.
|
||||
// 3. PositionManager.ComputeOffset returns ONLY the
|
||||
// InterpolationManager catch-up correction (with seqVel=0).
|
||||
// Retail's CPartArray::Update writes a tiny per-anim-frame
|
||||
// stride into the offset frame; PositionManager::adjust_offset
|
||||
// either lets it through or REPLACES it with catch-up. Our
|
||||
// AnimationSequencer.CurrentVelocity is the SYNTHESIZED
|
||||
// RunAnimSpeed × speedMod (matches body.Velocity), NOT a
|
||||
// per-anim-frame stride — passing it as root motion
|
||||
// double-counts the bulk translation that body.Velocity
|
||||
// already provides via UpdatePhysicsInternal. Pass zero
|
||||
// so only the queue-correction reaches the body.
|
||||
// 4. Apply correction to body.Position.
|
||||
// 5. Sequencer omega → body orientation (turn cycles).
|
||||
// 6. calc_acceleration + UpdatePhysicsInternal — Euler-
|
||||
// integrates body.Position += body.Velocity × dt.
|
||||
|
||||
// Sequencer-driven motion sources: linear velocity (root motion)
|
||||
// AND angular velocity (turn-cycle omega).
|
||||
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1+2: animation root motion + Interp correction (combined via PositionManager).
|
||||
// Step 1: grounded flags so apply_current_movement writes velocity.
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
|
||||
// Step 2: refresh body.Velocity from current motion state.
|
||||
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 3+4: queue catch-up correction only (no double-count of seqVel).
|
||||
float maxSpeed = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVel,
|
||||
seqVel: System.Numerics.Vector3.Zero,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeed);
|
||||
rm.Body.Position += offset;
|
||||
|
||||
// Step 2.5: animation-driven rotation. Retail's sequencer bakes Omega
|
||||
// for TurnRight/TurnLeft cycles; we apply it as a per-frame quaternion
|
||||
// rotation. seqOmega is body-local angular velocity (axis-angle: axis
|
||||
// is omega.Normalized, magnitude is rad/sec). For Z-axis turns it's
|
||||
// (0, 0, ±π/2 × turnSpeed) typically.
|
||||
if (seqOmega.LengthSquared() > 1e-9f)
|
||||
// Step 2.5: angular velocity → body orientation. Prefer
|
||||
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
|
||||
// the wire's TurnCommand + signed TurnSpeed) over the
|
||||
// sequencer's synthesized omega: when the player runs in
|
||||
// a circle ACE broadcasts ForwardCommand=RunForward AND
|
||||
// TurnCommand=TurnLeft on the same UpdateMotion. The
|
||||
// sequencer's animCycle picker chooses RunForward (legs
|
||||
// running), whose synthesized CurrentOmega is zero. Body
|
||||
// would not rotate between UPs and body.Velocity stays in
|
||||
// an out-of-date world direction, producing the
|
||||
// user-reported "rectangle when running circles" effect.
|
||||
// ObservedOmega has the correct turn rate even when the
|
||||
// visible cycle is RunForward.
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f
|
||||
? rm.ObservedOmega
|
||||
: seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = seqOmega.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(seqOmega);
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
|
|
@ -5906,7 +6006,9 @@ public sealed class GameWindow : IDisposable
|
|||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||
System.Console.WriteLine(
|
||||
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
|
||||
+ $"seqOmega.Z={seqOmega.Z:F3} (Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
||||
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
|
||||
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
|
||||
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
||||
rm.LastOmegaDiagLogTime = nowSec;
|
||||
}
|
||||
}
|
||||
|
|
@ -5945,39 +6047,48 @@ public sealed class GameWindow : IDisposable
|
|||
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 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
|
||||
// Compare the sequencer's body-local CurrentVelocity (root motion
|
||||
// we're applying per tick) against the server's effective
|
||||
// broadcast pace ((LastServerPos - PrevServerPos) / Δt). If
|
||||
// |seqVel| significantly exceeds |serverVel|, the body
|
||||
// overshoots between UPs and the InterpolationManager has to
|
||||
// walk it backward each waypoint — visible as 1-Hz blips.
|
||||
// The ratio prints once per remote per ~2 seconds so a moving
|
||||
// remote shows up without flooding the console.
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||||
&& rm.PrevServerPosTime > 0.0
|
||||
&& rm.LastServerPosTime > rm.PrevServerPosTime)
|
||||
// Track the maximum sequencer velocity magnitude seen since
|
||||
// the last UpdatePosition arrival (carried on the
|
||||
// RemoteMotion struct), then on each UP arrival the
|
||||
// OnLivePositionUpdated path prints the comparison against
|
||||
// the server's actual broadcast pace
|
||||
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
|
||||
// both sides are sampled during the same window and the
|
||||
// ratio reflects the real overshoot.
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
{
|
||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
if (nowSec - rm.LastVelDiagLogTime > 2.0)
|
||||
{
|
||||
double dtServer = rm.LastServerPosTime - rm.PrevServerPosTime;
|
||||
var serverDelta = rm.LastServerPos - rm.PrevServerPos;
|
||||
float serverSpeed = (float)(serverDelta.Length() / dtServer);
|
||||
float seqSpeed = seqVel.Length();
|
||||
// Only log when the entity is actually moving — skip
|
||||
// idle remotes where both speeds are ~0.
|
||||
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[VEL_DIAG] guid={serverGuid:X8} seqSpeed={seqSpeed:F3} m/s "
|
||||
+ $"serverSpeed={serverSpeed:F3} m/s "
|
||||
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
|
||||
rm.LastVelDiagLogTime = nowSec;
|
||||
}
|
||||
}
|
||||
// body.Velocity is now the source of bulk translation
|
||||
// (set above by apply_current_movement). Track its
|
||||
// magnitude so VEL_DIAG can compare against the actual
|
||||
// server broadcast pace.
|
||||
float seqSpeedNow = rm.Body.Velocity.Length();
|
||||
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
|
||||
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
|
||||
}
|
||||
|
||||
ae.Entity.Position = rm.Body.Position;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue