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:
parent
5f2e2e28ff
commit
8fa04af4c7
4 changed files with 679 additions and 8 deletions
|
|
@ -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 ~5–10 Hz, so
|
||||
/// a Shift toggle's first UP after the toggle is typically ~100–200 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 ≈ 8–12 m/s
|
||||
/// (RunAnimSpeed × runRate ≈ 4.0 × 2.0–3.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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue