diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 0847e5ce..42d7a233 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -3108,7 +3108,20 @@ regression on the species that already render correctly.
## #39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer)
-**Status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test)
+**Status:** CLOSED 2026-07-02 (superseded by L.2g; refinement machinery DELETED in the S5 commit)
+**Closure note:** the root-cause narrative below is WRONG — the 2026-05-06
+"wire goes silent on Shift toggle" finding was refuted at three oracles + a
+fresh live capture (`docs/research/2026-07-02-inbound-motion-deviation-map.md`,
+S0 section): retail sends a fresh MoveToState on HoldRun toggle while moving
+(`CommandInterpreter` 0x006b37a8 → `SendMovementEvent`), ACE rebroadcasts every
+MoveToState (`GameActionMoveToState.cs:36`), and the S0 capture shows explicit
+`0x0005↔0x0007` UMs on each toggle. Retail has NO pace→cycle adaptation
+anywhere (DEV-2). The `ApplyPlayerLocomotionRefinement` layer this issue added
+was itself causing Ready↔Run thrash against legitimate flags=0 stop UMs and
+was deleted; player-remote cycles are UM-driven only. Remaining transition
+polish (funnel, stop path, link pose) is tracked as roadmap L.2g S2–S4.
+
+**Original status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test)
**Severity:** LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip — believed to work via direct UM but not explicitly exercised)
**Filed:** 2026-05-03
**Component:** physics / motion / animation
diff --git a/docs/research/2026-07-02-inbound-motion-deviation-map.md b/docs/research/2026-07-02-inbound-motion-deviation-map.md
index da9f5c37..01877eae 100644
--- a/docs/research/2026-07-02-inbound-motion-deviation-map.md
+++ b/docs/research/2026-07-02-inbound-motion-deviation-map.md
@@ -258,6 +258,46 @@ bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically;
and it is NOT a dead-reckoning-tuning problem — the chase is already retail,
it's the inputs (cycle + velocity source + stop signal) that deviate.
+## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture)
+
+Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured
+protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`).
+
+1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles
+ arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007
+ fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's
+ default-difference packing baselines `forward_command` against **Ready**
+ (`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is
+ non-default and always packed, so ACE (`MovementData.cs:104-119`) always
+ re-emits it with the holdKey upgrade + run-skill speed.
+2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE**
+ — they are genuine keys-released / heading-only MoveToState relays
+ (all-default raw state packs nothing; ACE `RawMotionState.Read` leaves
+ absent = Invalid; `MovementData` emits nothing). Retail semantics
+ (verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults
+ `forward_command=Ready`; `MovementManager::unpack_movement` case 0
+ UnPacks into a default-constructed state;
+ `CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT
+ `copy_movement_from` + `apply_current_movement`): an empty UM = a real
+ full stop. acdream already maps it to Ready — but the DEV-2 refinement
+ then re-promotes the cycle from UP pace after the 0.2 s grace, producing
+ a Ready↔Run thrash against legitimate stop signals. That thrash is the
+ observed flicker/rubber-band component.
+3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs;
+ movementSeq advances +1 per UM; byte-level layout confirmed
+ (`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where
+ the parser reads them).
+4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the
+ outer style u16 routes through `command_ids[]` and applies via
+ `DoMotion` ONLY when it differs from the current style; motionFlags
+ carries sticky-object (0x100 → `stick_to_object`) and standing_longjump
+ (0x200); the actions list is consumed under a 15-bit
+ `server_action_stamp` wraparound compare
+ (`move_to_interpreted_state` 0x005289c0, lines 305953-305989).
+
+**Disposition: S5 = delete the player pace-inference layer** (executed in the
+same session; NPC `PlanFromVelocity` path untouched — its unification is S6).
+
## Provenance
- Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port /
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 9b711550..68a5e2ce 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -570,19 +570,6 @@ public sealed class GameWindow : IDisposable
///
public float MaxSeqSpeedSinceLastUP;
- ///
- /// 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.
- ///
- 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;
}
- ///
- /// 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 5–10 Hz, so the first UP after a
- /// fresh UM lands ~100–200 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.
- ///
- private const double UmGraceSeconds = 0.2;
-
- ///
- /// 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
- /// avoids thrashing at the boundary.
- ///
- private const float PlayerRunPromoteSpeed = 5.5f;
-
- ///
- /// Speed (m/s) below which a player-remote currently in RunForward
- /// is demoted to WalkForward by velocity refinement.
- ///
- 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";