fix(motion): #39 candidate — un-gate UP velocity-cycle for player remotes (forward only)

Adds a player-remote velocity-fallback path to ApplyServerControlledVelocityCycle
so that when retail (the actor) toggles Shift while holding W and acdream is
the observer, the visible leg cycle switches Run↔Walk within ~200–500 ms even
though no fresh UM arrives. Static analysis (ACE GameActionMoveToState +
MovementData.cs auto-upgrade + acdream's prior diag traces) suggests retail
does NOT broadcast a fresh MoveToState on HoldKey-only changes — acdream's
UMs handle direction-key changes and our local +Acdream's transitions, but
retail-driven actors leave the cycle stuck.

Changes (all in src/AcDream.App/Rendering/GameWindow.cs):
- New RemoteMotion.LastUMTime field, stamped in OnLiveMotionUpdated
- ApplyServerControlledVelocityCycle: removed inner IsPlayerGuid gate;
  routes player remotes to new ApplyPlayerLocomotionRefinement
- ApplyPlayerLocomotionRefinement (forward-direction only):
  - 500 ms UM grace window (UMs win when fresh)
  - Forward-direction-only (low byte 0x05 / 0x07)
  - Hysteresis: Run → Walk demote at < 4.5 m/s; Walk → Run promote > 5.5 m/s
  - Skip SetCycle when neither motion ID nor speedMod changed meaningfully
  - [UPCYCLE_PLAYER] diag gated on ACDREAM_REMOTE_VEL_DIAG=1
- Outer call site in OnLivePositionUpdated un-gated (!IsPlayerGuid removed);
  per-remote routing now lives inside the function

Scope: case #1 (Run↔Walk forward) only. Cases #2–#7 (backward, sidestep
speed-buckets, direction-flips) remain deferred — PlanFromVelocity is
forward-only and its NPC-tuned thresholds (RunThreshold=1.25) do not
separate player Walk (~2.5 m/s) from player Run (~9 m/s); a TTD trace
of retail's per-direction algorithm should ground the wider fix.

ISSUES.md #39 updated with progress; investigation-prompt.md and a new
findings-static.md committed under
docs/research/2026-05-06-locomotion-cycle-transitions/ (the prompt was
authored on a parallel branch in commit 7a38da3 and is brought into this
worktree here so the next session can find it without branch-hopping).

Build clean. The 8 pre-existing test failures on this branch
(BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope, MotionInterpreter
WalkBackward GetMaxSpeed, etc.) are unrelated to this change — verified
by running them with the diff stashed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-06 06:34:20 +02:00
parent 5f2e2e28ff
commit 8fa04af4c7
4 changed files with 679 additions and 8 deletions

View file

@ -383,6 +383,19 @@ public sealed class GameWindow : IDisposable
/// </summary>
public float MaxSeqSpeedSinceLastUP;
/// <summary>
/// Seconds-since-epoch timestamp of the most recent UpdateMotion (UM)
/// for this remote. Used by the player-remote velocity-fallback cycle
/// refinement to skip refinement while a fresh UM is authoritative —
/// retail's outbound MoveToState gives us direction-explicit cycles
/// on direction-key changes (W press, W release, W↔S flip), and we
/// only want UP-derived velocity to refine the speed bucket within
/// a direction when no UM has arrived recently. Defaults to 0
/// (epoch) so the first UP after spawn is allowed to refine
/// immediately if velocity already differs from the spawn cycle.
/// </summary>
public double LastUMTime;
public RemoteMotion()
{
Body = new AcDream.Core.Physics.PhysicsBody
@ -2590,6 +2603,19 @@ public sealed class GameWindow : IDisposable
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
// #39 (2026-05-06): stamp the per-remote LastUMTime so the
// UP-velocity fallback path in ApplyServerControlledVelocityCycle
// can skip refinement while a UM is fresh. UMs are authoritative
// for direction-key changes (W press / release / W↔S flip);
// velocity refinement only helps for HoldKey-only changes (Shift
// toggle while a direction key is held — retail does NOT broadcast
// a fresh MoveToState in that case).
if (_remoteDeadReckon.TryGetValue(update.Guid, out var rmStateForUm))
{
rmStateForUm.LastUMTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
}
// Re-resolve using the new stance/command. Keep the setup and
// motion-table we already know about — the server's motion
// updates override state within the same table, not swap tables.
@ -3317,13 +3343,38 @@ public sealed class GameWindow : IDisposable
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
}
/// <summary>
/// Grace window in seconds after a UM arrives during which UP-derived
/// velocity refinement is suppressed for a player remote. UMs are
/// authoritative; the velocity fallback only fills the gap when retail
/// does not send a fresh MoveToState (Shift toggle while direction key
/// held). 0.5 s is a defensible default — UPs arrive at ~510 Hz, so
/// a Shift toggle's first UP after the toggle is typically ~100200 ms
/// after the most recent UM, well past the grace.
/// </summary>
private const double UmGraceSeconds = 0.5;
/// <summary>
/// Speed (m/s) above which a player-remote currently in WalkForward
/// is promoted to RunForward by velocity refinement. Tuned to player
/// speeds: walk ≈ 3.12 m/s (WalkAnimSpeed × 1.0), run ≈ 812 m/s
/// (RunAnimSpeed × runRate ≈ 4.0 × 2.03.0). Hysteresis with
/// <see cref="PlayerRunDemoteSpeed"/> avoids thrashing at the boundary.
/// </summary>
private const float PlayerRunPromoteSpeed = 5.5f;
/// <summary>
/// Speed (m/s) below which a player-remote currently in RunForward
/// is demoted to WalkForward by velocity refinement.
/// </summary>
private const float PlayerRunDemoteSpeed = 4.5f;
private void ApplyServerControlledVelocityCycle(
uint serverGuid,
AnimatedEntity ae,
RemoteMotion rm,
System.Numerics.Vector3 velocity)
{
if (IsPlayerGuid(serverGuid)) return;
if (rm.Airborne) return;
if (ae.Sequencer is null) return;
// MoveTo packets already seeded the retail speed/runRate cycle.
@ -3332,6 +3383,32 @@ public sealed class GameWindow : IDisposable
// velocity-estimated animation.
if (rm.ServerMoveToActive) return;
if (IsPlayerGuid(serverGuid))
{
// #39 (2026-05-06): player-remote forward-direction speed-bucket
// refinement. The bug case: actor toggles Shift while holding W
// (or releases Shift). Retail's outbound apparently does NOT
// broadcast a fresh MoveToState for HoldKey-only changes
// (verified via static analysis of CommandInterpreter::SendMovementEvent
// call sites; needs cdb confirmation). ACE has nothing to
// broadcast → no UM arrives at the observer → cycle stays at
// whichever direction-bucket was last set. Velocity DOES change
// (UP carries new pace), so this code path uses UP-derived
// velocity to refine the speed bucket within the same direction.
//
// Conservative scope:
// - Forward direction only (low byte 0x05 or 0x07). Sidestep
// and backward HoldKey toggles are deferred until the TTD
// trace described in
// docs/research/2026-05-06-locomotion-cycle-transitions/
// confirms retail's exact algorithm.
// - Hysteresis (4.5 m/s demote / 5.5 m/s promote) prevents
// thrashing at the boundary.
// - 500 ms UM grace window — a fresh UM is always authoritative.
ApplyPlayerLocomotionRefinement(serverGuid, ae, rm, velocity);
return;
}
var plan = AcDream.Core.Physics.ServerControlledLocomotion
.PlanFromVelocity(velocity);
uint currentMotion = ae.Sequencer.CurrentMotion;
@ -3344,10 +3421,7 @@ public sealed class GameWindow : IDisposable
// D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
// ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
// SetCycle for player-driven remotes. If this fires every ~100-200ms
// during a Walk→Run press with `motion` flipping between buckets,
// H2 (UP-vs-UM race) is confirmed. UPs (5-10 Hz) would then
// perpetually overwrite the cycle the UM just set.
// SetCycle for non-player remotes (NPCs / monsters).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
@ -3361,6 +3435,103 @@ public sealed class GameWindow : IDisposable
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
}
private void ApplyPlayerLocomotionRefinement(
uint serverGuid,
AnimatedEntity ae,
RemoteMotion rm,
System.Numerics.Vector3 velocity)
{
// UM grace: a fresh UM is authoritative.
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
double sinceUm = nowSec - rm.LastUMTime;
if (sinceUm < UmGraceSeconds) return;
uint currentMotion = ae.Sequencer!.CurrentMotion;
uint lowByte = currentMotion & 0xFFu;
// Forward-only refinement scope. WalkForward = 0x05, RunForward = 0x07.
// Sidestep (0x0F/0x10), WalkBackward (0x06), turns and any other
// motion (emote, attack, etc.) are left to UM-driven SetCycle.
const uint LowWalkForward = 0x05u;
const uint LowRunForward = 0x07u;
bool isForward = lowByte == LowWalkForward || lowByte == LowRunForward;
if (!isForward) return;
float horizSpeed = MathF.Sqrt(velocity.X * velocity.X + velocity.Y * velocity.Y);
// Hysteresis: stay in current bucket unless we cross the appropriate
// threshold. Below StopSpeed → don't refine (let UM Ready stop signal
// handle the stop transition; we don't want UP momentary 0-velocity
// to drop the cycle to Ready while the actor is mid-stride).
if (horizSpeed < AcDream.Core.Physics.ServerControlledLocomotion.StopSpeed)
return;
uint targetMotion;
float speedMod;
if (lowByte == LowRunForward)
{
if (horizSpeed < PlayerRunDemoteSpeed)
{
targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward;
speedMod = MathF.Min(MathF.Max(
horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
}
else
{
targetMotion = AcDream.Core.Physics.MotionCommand.RunForward;
speedMod = MathF.Min(MathF.Max(
horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
}
}
else
{
// currently WalkForward (0x05)
if (horizSpeed > PlayerRunPromoteSpeed)
{
targetMotion = AcDream.Core.Physics.MotionCommand.RunForward;
speedMod = MathF.Min(MathF.Max(
horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
}
else
{
targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward;
speedMod = MathF.Min(MathF.Max(
horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
}
}
// Skip the SetCycle if neither motion nor speedMod changed
// meaningfully — avoids replaying transition links every UP.
bool motionChanged = currentMotion != targetMotion
&& (currentMotion & 0xFFu) != (targetMotion & 0xFFu);
bool speedChanged = MathF.Abs(ae.Sequencer.CurrentSpeedMod - speedMod) > 0.05f;
if (!motionChanged && !speedChanged)
return;
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
$"[UPCYCLE_PLAYER] guid={serverGuid:X8} "
+ $"|v|={horizSpeed:F2} cur=0x{currentMotion:X8} "
+ $"-> motion=0x{targetMotion:X8} speedMod={speedMod:F2} "
+ $"sinceUM={sinceUm:F2}s "
+ $"motionChg={motionChanged} speedChg={speedChanged}");
}
uint style = ae.Sequencer.CurrentStyle != 0
? ae.Sequencer.CurrentStyle
: 0x8000003Du;
ae.Sequencer.SetCycle(style, targetMotion, speedMod);
}
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
@ -3685,15 +3856,20 @@ public sealed class GameWindow : IDisposable
rmState.Body.Velocity = rmState.ServerVelocity;
}
if (!IsPlayerGuid(update.Guid)
&& rmState.HasServerVelocity
if (rmState.HasServerVelocity
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
{
// #39 (2026-05-06): un-gated for player remotes — the
// function itself routes player remotes into the dedicated
// ApplyPlayerLocomotionRefinement path (forward-direction
// speed bucket only, with UM grace + hysteresis). Non-player
// remotes use the existing PlanFromVelocity path.
//
// D2 (Commit A 2026-05-03): tag whether the velocity feeding
// ApplyServerControlledVelocityCycle is wire-explicit (rare for
// player remotes — ACE almost never sets HasVelocity on player
// UPs) or synthesized from position deltas (the common case).
// Pairs with the [UPCYCLE] line printed inside the call.
// Pairs with the [UPCYCLE]/[UPCYCLE_PLAYER] line printed inside.
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
string velSrc = update.Velocity is null ? "synth" : "wire";