feat(L.2g-S5): delete player pace-inference layer; close #39 (DEV-2)

S0 wire probe (live capture, retail actor via ACE) refuted the premise
the #39 machinery was built on: walk<->run Shift toggles arrive as
EXPLICIT UMs (0x0005 <-> 0x0007@runRate) because retail's default-
difference packing baselines forward_command against Ready — W-held is
always packed, and ACE re-emits it unconditionally with the holdKey
upgrade (MovementData.cs:104-119). The frequent flags=0 autonomous UMs
are genuine keys-released / heading-only states; retail applies them as
full stops (InterpretedMotionState ctor 0x0051e8d0 defaults Ready +
move_to_interpreted_state 0x005289c0 flat copy).

Retail has NO pace->animation adaptation anywhere in its inbound
pipeline (two decomp dives + ACE cross-check, deviation map DEV-2), and
the refinement layer's 0.2s-grace re-promotion after legitimate Ready
UMs was itself the observed Ready<->Run thrash / rubber-band component.

Deleted: ApplyPlayerLocomotionRefinement, UmGraceSeconds,
PlayerRunPromoteSpeed/PlayerRunDemoteSpeed, RemoteMotion.LastUMTime,
the synth-player refinement call in OnLivePositionUpdated. Player-remote
cycles are now UM-driven only, exactly like retail. NPC PlanFromVelocity
path untouched (S6 unifies).

S0 findings + S1 live validation (0 false UM_STALE drops across 280 UMs)
recorded in docs/research/2026-07-02-inbound-motion-deviation-map.md.

fix #39: closed — root-cause narrative corrected, machinery removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 16:10:28 +02:00
parent cb74e64343
commit a2f8104cbf
3 changed files with 87 additions and 274 deletions

View file

@ -570,19 +570,6 @@ 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
@ -4288,19 +4275,6 @@ public sealed class GameWindow : IDisposable
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.
@ -5135,35 +5109,6 @@ 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). 200 ms covers the worst-case UM/UP race — UMs arrive on
/// direction-key events and UPs at 510 Hz, so the first UP after a
/// fresh UM lands ~100200 ms behind. Tightened from 500 ms (commit
/// 8fa04af original) per user observation that the Shift-toggle
/// transition was visibly slower than retail; with 0.2 s the worst-case
/// added latency is just the UP cadence below it.
/// </summary>
private const double UmGraceSeconds = 0.2;
/// <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,
@ -5180,27 +5125,24 @@ public sealed class GameWindow : IDisposable
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.
// L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
// pace-derived cycle refinement — retail has no such mechanism
// anywhere in its inbound pipeline (deviation map DEV-2, two
// independent decomp dives + ACE cross-check). The #39-era
// premise ("retail's outbound goes silent on Shift toggle") was
// refuted at all three oracles + the S0 live capture: retail
// sends a fresh MoveToState on HoldRun toggle while moving
// (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
// rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
// and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
// (launch-s0-wireprobe.log). The refinement layer's re-promote
// after legitimate flags=0 Ready UMs was itself the observed
// Ready↔Run thrash. Cycle changes for player remotes come from
// UpdateMotion ONLY, exactly like retail; position error is the
// InterpolationManager chase's job.
//
// 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);
// NPC/monster remotes below keep PlanFromVelocity until S6
// unifies all entity classes onto the CMotionInterp funnel.
return;
}
@ -5230,162 +5172,6 @@ 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;
float currentSign = MathF.Sign(ae.Sequencer.CurrentSpeedMod);
if (currentSign == 0f) currentSign = 1f;
// Recognised locomotion directions:
// 0x05 (WalkForward) — also encodes WalkBackward via negative speed
// (ACE convention: SidestepCommand= cancel, ForwardCommand=
// WalkForward, ForwardSpeed *= -0.65)
// 0x07 (RunForward)
// 0x0F (SideStepRight)
// 0x10 (SideStepLeft)
// Other motions (Ready, Turn, emotes, attacks) are left to UM-driven SetCycle.
const uint LowWalkForward = 0x05u;
const uint LowRunForward = 0x07u;
const uint LowSideStepRight = 0x0Fu;
const uint LowSideStepLeft = 0x10u;
bool isForwardClass = lowByte == LowWalkForward || lowByte == LowRunForward;
bool isSidestep = lowByte == LowSideStepRight || lowByte == LowSideStepLeft;
if (!isForwardClass && !isSidestep) 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 (isSidestep)
{
// Sidestep: motion ID stays the same (SideStepLeft / SideStepRight).
// Retail's wire encoding for sidestep speed buckets uses the same
// motion ID with different SidestepSpeed (slow ≈ 1.25 multiplier,
// fast ≈ 3.0 clamp per ACE MovementData.cs:124-131). On Shift
// toggle while a strafe key is held, retail does NOT broadcast a
// fresh MoveToState (same wire-silence rule as the forward case),
// so observer-side cycle refinement must come from UP-derived
// velocity here. Preserve the sign — SideStepLeft is sometimes
// emitted with negative speedMod by the adjust_motion path.
//
// Magnitude: horizSpeed / SidestepAnimSpeed maps the observed
// speed back to a SideStepSpeed the sequencer can apply as a
// framerate multiplier. Retail's get_state_velocity for
// sidestep cycles is `velocity.X = SidestepAnimSpeed *
// SideStepSpeed` (MotionInterpreter.cs:592 — constant 1.25
// m/s). Dividing by WalkAnimSpeed (3.12) here was wrong by
// 2.5× and made slow strafe play visibly slower than retail.
float sideMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed;
sideMag = MathF.Min(MathF.Max(
sideMag,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
targetMotion = currentMotion;
speedMod = sideMag * currentSign;
}
else if (currentSign < 0f)
{
// BACKWARD walk: ACE encodes WalkBackward as `WalkForward` motion
// with NEGATIVE speedMod (MovementData.cs:115 `interpState.ForwardSpeed *= -0.65f`).
// No "RunBackward" motion exists — Shift toggle on backward
// changes only the magnitude of speedMod (slow back ≈ -0.65,
// fast back ≈ -1.91 = -runRate × 0.65). Keep WalkForward motion,
// refine magnitude, preserve negative sign.
//
// Without this branch (the original fix #1), backward refinement
// computed a positive speedMod from horizSpeed and overwrote the
// negative sign, making the legs play forward-walk while the body
// continued moving backward (the rubber-banding the user reported).
float backMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed;
backMag = MathF.Min(MathF.Max(
backMag,
AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod),
AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod);
targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward;
speedMod = -backMag;
}
else 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) with positive speedMod = walking forward.
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 / #135: track the PLAYER's last server-known landblock so the
@ -5634,24 +5420,14 @@ public sealed class GameWindow : IDisposable
isMovingTo: false,
currentBodyPosition: rmState.Body.Position);
}
// #39 fix-3 (2026-05-06): velocity-fallback cycle refinement
// for player remotes. Wire-level evidence (`launch-39-diag2.log`):
// when retail's actor toggles Shift while a direction key
// is held, retail's outbound MoveToState logic does NOT
// emit a fresh packet (only Ready ↔ Run UMs visible in
// `[FWD_WIRE]`, despite a clear walk-pace ↔ run-pace
// velocity transition in `[VEL_DIAG]`). ACE has nothing
// to broadcast → no UM arrives at the observer → cycle
// sticks at whatever the last UM set. Compute the
// synth-velocity here in the player-remote path AND
// call into ApplyServerControlledVelocityCycle, which
// routes through the direction-preserving + UM-grace
// ApplyPlayerLocomotionRefinement helper (added in
// commit 8fa04af).
//
// The legacy non-player block below (3759+) covers NPCs
// and is gated `!IsPlayerGuid`; this block fills the
// matching gap for players.
// Track the UP-derived synth velocity for diagnostics
// ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the
// #39-era cycle-refinement call that used to live here is
// DELETED — retail never adapts a remote's animation from
// observed pace (deviation map DEV-2; premise refuted at
// decomp + ACE source + the S0 live capture, which shows
// explicit 0x0005↔0x0007 UMs on every Shift toggle).
// Player-remote cycles are UM-driven only.
if (rmState.PrevServerPosTime > 0.0)
{
double nowSecVel = rmState.LastServerPosTime;
@ -5661,21 +5437,6 @@ public sealed class GameWindow : IDisposable
var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos;
rmState.ServerVelocity = synthVel;
rmState.HasServerVelocity = true;
if (_animatedEntities.TryGetValue(entity.Id, out var aeForVel)
&& aeForVel.Sequencer is not null)
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
System.Console.WriteLine(
$"[UPCYCLE_SRC] guid={update.Guid:X8} src=synth-player");
}
ApplyServerControlledVelocityCycle(
update.Guid,
aeForVel,
rmState,
synthVel);
}
}
}
@ -5793,17 +5554,16 @@ public sealed class GameWindow : IDisposable
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.
// NPC/monster remotes: PlanFromVelocity cycle selection from
// UP-derived velocity (ACE broadcasts NPC motion patterns the
// UM stream alone doesn't cover). Player remotes return early
// inside — their cycles are UM-driven only per retail (L.2g
// S5; DEV-2 deleted). Unification of NPCs onto the
// CMotionInterp funnel is S6.
//
// 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]/[UPCYCLE_PLAYER] line printed inside.
// ApplyServerControlledVelocityCycle is wire-explicit or
// synthesized from position deltas (the common case).
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
string velSrc = update.Velocity is null ? "synth" : "wire";