fix(motion): #39 — wire ApplyServerControlledVelocityCycle into player-remote path

Visual verify with the proper Shift-toggle scenario revealed that fix #1's
ApplyPlayerLocomotionRefinement was UNREACHABLE for player remotes — the
L.3 M2 routing at line 3626+ returns at line 3755, BEFORE the call site
at line 3879. The legacy NPC-only block that compute server velocity +
calls ApplyServerControlledVelocityCycle never runs for players.

[UPCYCLE_PLAYER] count = 0 in launch-39-fix2.log and launch-39-diag2.log
proved the velocity-fallback path was completely dead code for players.

Wire-level evidence (launch-39-diag2.log):

- [FWD_WIRE] for retail actor 0x50000001 over a clean Hold-W-press-Shift-
  release-Shift-release-W test shows ONLY Ready→Run and Run→Ready
  transitions. NO Walk wire transitions for the Shift toggle. So retail's
  outbound MoveToState logic does NOT emit a fresh packet on HoldKey-only
  changes (refutes the launch-39-fix2 hypothesis that both directions
  emit; the earlier fix2 log's many Walk↔Run transitions came from
  W press/release cycles WITH Shift held continuously, not from Shift
  toggling alone).
- [VEL_DIAG] over the same test shows clear walk-pace (~2.5 m/s) and
  run-pace (~11.5 m/s) periods, so the actor's actual physical speed
  IS changing despite the wire silence.

Fix: in OnLivePositionUpdated's L.3 M2 player-remote block, after the
near-enqueue / far-snap routing and before the early `return`, compute
synth velocity from PrevServerPos / LastServerPos and call into
ApplyServerControlledVelocityCycle. The function's internal routing
(commit 8fa04af) sends player remotes through ApplyPlayerLocomotionRefinement
which has the 500 ms UM grace + forward-direction + hysteresis logic
to flip Run↔Walk only when no fresh UM is authoritative.

Build clean. Diagnostics: [UPCYCLE_SRC] now prints `src=synth-player`
when the player-remote path fires (distinct from `src=synth`/`src=wire`
in the legacy NPC path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-06 08:25:10 +02:00
parent bb026b7991
commit 2653b307c7

View file

@ -3745,6 +3745,51 @@ public sealed class GameWindow : IDisposable
isMovingTo: false, isMovingTo: false,
currentBodyPosition: rmState.Body.Position); 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.
if (rmState.PrevServerPosTime > 0.0)
{
double nowSecVel = rmState.LastServerPosTime;
double dtPos = nowSecVel - rmState.PrevServerPosTime;
if (dtPos > 0.001)
{
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);
}
}
}
// Sync the visible entity to the body — overrides the unconditional // Sync the visible entity to the body — overrides the unconditional
// entity.Position = worldPos snap at the top of this function. // entity.Position = worldPos snap at the top of this function.
// For the far-snap branch this is a no-op (body == worldPos); for // For the far-snap branch this is a no-op (body == worldPos); for