diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index bc6e49f..3b76a52 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -375,8 +375,14 @@ public sealed class GameWindow : IDisposable
///
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
- public double LastVelDiagLogTime;
public double LastOmegaDiagLogTime;
+ ///
+ /// 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.
+ ///
+ 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;
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 1930b44..f82c1d3 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -935,34 +935,38 @@ public sealed class MotionInterpreter
// ── CMotionInterp::get_max_speed (0x00527cb0) ─────────────────────────────
///
- /// Return the motion-table-derived max speed (m/s) for the current
- /// .
+ /// Return the run rate. Mirrors retail
+ /// CMotionInterp::get_max_speed at 0x00527cb0.
///
///
- /// Retail reference (named-retail, 0x00527cb0):
- /// CMotionInterp::get_max_speed fetches the run rate via
- /// InqRunRate (or falls back to my_run_rate) and returns
- /// the result as a float from the x87 FPU stack (ST0). The Binary Ninja
- /// decompiler emits a spurious void return type for x87-returning
- /// functions — the actual return value is confirmed by the two callers:
- /// StickyManager::adjust_offset (0x00555430) and
- /// InterpolationManager::AdjustOffset (0x00555d52), both of which
- /// multiply the result by 2.0 to produce a catch-up speed in m/s. With a
- /// run rate of ~1.0 the catch-up would be 2.0 m/s — far too slow — so the
- /// function must return the actual velocity (m/s), not a bare rate.
+ /// Decomp (named-retail/acclient_2013_pseudo_c.txt:305127):
+ ///
+ /// void get_max_speed(this) {
+ /// weenie_obj = this->weenie_obj;
+ /// this_1 = nullptr;
+ /// if (weenie_obj == 0) return;
+ /// if (weenie_obj->vtable->InqRunRate(&this_1) != 0) return;
+ /// this->my_run_rate; // x87 fld leaves my_run_rate on FPU stack
+ /// }
+ ///
+ /// Binary Ninja shows the return type as void because the float
+ /// return rides the x87 FPU stack rather than EAX. Both branches
+ /// emit an fld of either this_1 (the InqRunRate
+ /// out-param value) or my_run_rate, leaving the run rate on
+ /// ST0 as the return value.
///
///
///
- /// The per-command switch mirrors get_state_velocity
- /// (0x00527d50), which uses the same constants and the same
- /// RunForward / WalkForward / WalkBackward branches, and the
- /// adjust_motion (0x00527c0e) BackwardsFactor = 0.65
- /// scaling confirmed at address 0x00528010.
- ///
- ///
- ///
- /// Used by InterpolationManager.AdjustOffset in L.3 Task 5
- /// as 2 × GetMaxSpeed() catch-up speed.
+ /// Critical: this returns the BARE run rate (typically 1.0 to
+ /// ~3.0), NOT a velocity in m/s. We previously multiplied by
+ /// RunAnimSpeed to get a m/s value, reasoning that
+ /// 2 × bare_rate would be too slow a catch-up speed for the
+ /// caller (InterpolationManager::adjust_offset). That was a
+ /// misread of the decomp — retail's catch-up IS that slow on purpose.
+ /// The multi-second 1-Hz blip the user reported when observing retail
+ /// remotes from acdream traced to body racing at the wrong (overshot)
+ /// catch-up speed (~23.5 m/s instead of the retail-correct ~5.9 m/s
+ /// for a run-skill-200 char).
///
///
public float GetMaxSpeed()
@@ -972,14 +976,7 @@ public sealed class MotionInterpreter
float rate = MyRunRate;
if (WeenieObj is not null && WeenieObj.InqRunRate(out float queried))
rate = queried;
-
- return InterpretedState.ForwardCommand switch
- {
- MotionCommand.RunForward => RunAnimSpeed * rate,
- MotionCommand.WalkForward => WalkAnimSpeed,
- MotionCommand.WalkBackward => WalkAnimSpeed * 0.65f, // BackwardsFactor @ adjust_motion 0x00528010
- _ => 0f, // idle / non-locomotion
- };
+ return rate;
}
// ── private helper ────────────────────────────────────────────────────────
diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs
index aa352ab..be3dbc0 100644
--- a/src/AcDream.Core/Physics/PositionManager.cs
+++ b/src/AcDream.Core/Physics/PositionManager.cs
@@ -42,14 +42,35 @@ public sealed class PositionManager
InterpolationManager interp,
float maxSpeed)
{
- // Step 1: animation root motion (body-local → world).
- Vector3 rootMotionLocal = seqVel * (float)dt;
- Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
-
- // Step 2: interpolation correction (world-space already).
+ // Retail-faithful per-frame combiner. Mirrors
+ // CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
+ // InterpolationManager::adjust_offset (@ 0x00555d30):
+ //
+ // 1. CPartArray::Update writes rootOffset (animation root motion)
+ // into the per-tick Frame.
+ // 2. PositionManager::adjust_offset → InterpolationManager::adjust_offset
+ // either:
+ // a) RETURNS EARLY when distance(body, head) < 0.05m
+ // (NodeCompleted; arg2 unmodified) — body uses root motion.
+ // b) OVERWRITES arg2 with `direction × min(catchUpSpeed × dt,
+ // distance)` when body is far from head — catch-up REPLACES
+ // root motion for this frame.
+ //
+ // It is NOT additive. Our prior port added rootMotion + correction
+ // every frame, which stacked the animation push (≈ RunAnimSpeed ×
+ // speedMod, ≈ 11.7 m/s) on top of the queue catch-up (capped at
+ // ≈ 23.5 m/s) so the body advanced at up to ~3× the server's
+ // broadcast pace and the head-behind-body case produced a backward
+ // correction every UP — the visible 1-Hz blip the user reported.
+ //
+ // AdjustOffset returns Vector3.Zero in two cases mapped to retail's
+ // early-return: empty queue OR distance < DesiredDistance (0.05m).
+ // In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
+ if (correction.LengthSquared() > 0f)
+ return correction;
- // Step 3: combined delta.
- return rootMotionWorld + correction;
+ Vector3 rootMotionLocal = seqVel * (float)dt;
+ return Vector3.Transform(rootMotionLocal, ori);
}
}